mirror of https://github.com/grafana/grafana.git
				
				
				
			Elasticsearch: Detect Elasticsearch version (#63341)
* elasticsearch: detect database version * more test-friendly code
This commit is contained in:
		
							parent
							
								
									f9abd8608e
								
							
						
					
					
						commit
						d73fdcfc11
					
				|  | @ -1,9 +1,9 @@ | |||
| import { cx } from '@emotion/css'; | ||||
| import React, { useCallback } from 'react'; | ||||
| import { satisfies } from 'semver'; | ||||
| import { satisfies, SemVer } from 'semver'; | ||||
| 
 | ||||
| import { SelectableValue } from '@grafana/data'; | ||||
| import { InlineSegmentGroup, Segment, SegmentAsync, useTheme2 } from '@grafana/ui'; | ||||
| import { InlineSegmentGroup, SegmentAsync, useTheme2 } from '@grafana/ui'; | ||||
| 
 | ||||
| import { useFields } from '../../../hooks/useFields'; | ||||
| import { useDispatch } from '../../../hooks/useStatelessReducer'; | ||||
|  | @ -41,15 +41,16 @@ const isBasicAggregation = (metric: MetricAggregation) => !metricAggregationConf | |||
| 
 | ||||
| const getTypeOptions = ( | ||||
|   previousMetrics: MetricAggregation[], | ||||
|   esVersion: string | ||||
|   esVersion: SemVer | null | ||||
| ): Array<SelectableValue<MetricAggregationType>> => { | ||||
|   // we'll include Pipeline Aggregations only if at least one previous metric is a "Basic" one
 | ||||
|   const includePipelineAggregations = previousMetrics.some(isBasicAggregation); | ||||
| 
 | ||||
|   return ( | ||||
|     Object.entries(metricAggregationConfig) | ||||
|       // Only showing metrics type supported by the configured version of ES
 | ||||
|       .filter(([_, { versionRange = '*' }]) => satisfies(esVersion, versionRange)) | ||||
|       // Only showing metrics type supported by the version of ES.
 | ||||
|       // if we cannot determine the version, we assume it is suitable.
 | ||||
|       .filter(([_, { versionRange = '*' }]) => (esVersion != null ? satisfies(esVersion, versionRange) : true)) | ||||
|       // Filtering out Pipeline Aggregations if there's no basic metric selected before
 | ||||
|       .filter(([_, config]) => includePipelineAggregations || !config.isPipelineAgg) | ||||
|       .map(([key, { label }]) => ({ | ||||
|  | @ -66,6 +67,11 @@ export const MetricEditor = ({ value }: Props) => { | |||
|   const dispatch = useDispatch(); | ||||
|   const getFields = useFields(value.type); | ||||
| 
 | ||||
|   const getTypeOptionsAsync = async (previousMetrics: MetricAggregation[]) => { | ||||
|     const dbVersion = await datasource.getDatabaseVersion(); | ||||
|     return getTypeOptions(previousMetrics, dbVersion); | ||||
|   }; | ||||
| 
 | ||||
|   const loadOptions = useCallback(async () => { | ||||
|     const remoteFields = await getFields(); | ||||
| 
 | ||||
|  | @ -85,9 +91,9 @@ export const MetricEditor = ({ value }: Props) => { | |||
|   return ( | ||||
|     <> | ||||
|       <InlineSegmentGroup> | ||||
|         <Segment | ||||
|         <SegmentAsync | ||||
|           className={cx(styles.color, segmentStyles)} | ||||
|           options={getTypeOptions(previousMetrics, datasource.esVersion)} | ||||
|           loadOptions={() => getTypeOptionsAsync(previousMetrics)} | ||||
|           onChange={(e) => dispatch(changeMetricType({ id: value.id, type: e.value! }))} | ||||
|           value={toOption(value)} | ||||
|         /> | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ import { QueryEditor } from '.'; | |||
| const noop = () => void 0; | ||||
| const datasourceMock = { | ||||
|   esVersion: '7.10.0', | ||||
|   getDatabaseVersion: () => Promise.resolve(null), | ||||
| } as ElasticDatasource; | ||||
| 
 | ||||
| describe('QueryEditor', () => { | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import React from 'react'; | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { SemVer } from 'semver'; | ||||
| 
 | ||||
| import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; | ||||
| import { Alert, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui'; | ||||
|  | @ -8,7 +9,7 @@ import { ElasticDatasource } from '../../datasource'; | |||
| import { useNextId } from '../../hooks/useNextId'; | ||||
| import { useDispatch } from '../../hooks/useStatelessReducer'; | ||||
| import { ElasticsearchOptions, ElasticsearchQuery } from '../../types'; | ||||
| import { isSupportedVersion } from '../../utils'; | ||||
| import { isSupportedVersion, unsupportedVersionMessage } from '../../utils'; | ||||
| 
 | ||||
| import { BucketAggregationsEditor } from './BucketAggregationsEditor'; | ||||
| import { ElasticsearchProvider } from './ElasticsearchQueryContext'; | ||||
|  | @ -18,14 +19,35 @@ import { changeAliasPattern, changeQuery } from './state'; | |||
| 
 | ||||
| export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchQuery, ElasticsearchOptions>; | ||||
| 
 | ||||
| export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range }: ElasticQueryEditorProps) => { | ||||
|   if (!isSupportedVersion(datasource.esVersion)) { | ||||
|     return ( | ||||
|       <Alert | ||||
|         title={`Support for Elasticsearch versions after their end-of-life (currently versions < 7.10) was removed`} | ||||
|       ></Alert> | ||||
| // a react hook that returns the elasticsearch database version,
 | ||||
| // or `null`, while loading, or if it is not possible to determine the value.
 | ||||
| function useElasticVersion(datasource: ElasticDatasource): SemVer | null { | ||||
|   const [version, setVersion] = useState<SemVer | null>(null); | ||||
|   useEffect(() => { | ||||
|     let canceled = false; | ||||
|     datasource.getDatabaseVersion().then( | ||||
|       (version) => { | ||||
|         if (!canceled) { | ||||
|           setVersion(version); | ||||
|         } | ||||
|       }, | ||||
|       (error) => { | ||||
|         // we do nothing
 | ||||
|         console.log(error); | ||||
|       } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|     return () => { | ||||
|       canceled = true; | ||||
|     }; | ||||
|   }, [datasource]); | ||||
| 
 | ||||
|   return version; | ||||
| } | ||||
| 
 | ||||
| export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range }: ElasticQueryEditorProps) => { | ||||
|   const elasticVersion = useElasticVersion(datasource); | ||||
|   const showUnsupportedMessage = elasticVersion != null && !isSupportedVersion(elasticVersion); | ||||
|   return ( | ||||
|     <ElasticsearchProvider | ||||
|       datasource={datasource} | ||||
|  | @ -34,6 +56,7 @@ export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range }: | |||
|       query={query} | ||||
|       range={range || getDefaultTimeRange()} | ||||
|     > | ||||
|       {showUnsupportedMessage && <Alert title={unsupportedVersionMessage} />} | ||||
|       <QueryEditorForm value={query} /> | ||||
|     </ElasticsearchProvider> | ||||
|   ); | ||||
|  |  | |||
|  | @ -6,7 +6,6 @@ import { Alert, DataSourceHttpSettings, SecureSocksProxySettings } from '@grafan | |||
| import { config } from 'app/core/config'; | ||||
| 
 | ||||
| import { ElasticsearchOptions } from '../types'; | ||||
| import { isSupportedVersion } from '../utils'; | ||||
| 
 | ||||
| import { DataLinks } from './DataLinks'; | ||||
| import { ElasticDetails } from './ElasticDetails'; | ||||
|  | @ -34,8 +33,6 @@ export const ConfigEditor = (props: Props) => { | |||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, []); | ||||
| 
 | ||||
|   const supportedVersion = isSupportedVersion(options.jsonData.esVersion); | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       {options.access === 'direct' && ( | ||||
|  | @ -43,11 +40,6 @@ export const ConfigEditor = (props: Props) => { | |||
|           Browser access mode in the Elasticsearch datasource is no longer available. Switch to server access mode. | ||||
|         </Alert> | ||||
|       )} | ||||
|       {!supportedVersion && ( | ||||
|         <Alert title="Deprecation notice" severity="error"> | ||||
|           {`Support for Elasticsearch versions after their end-of-life (currently versions < 7.10) was removed`} | ||||
|         </Alert> | ||||
|       )} | ||||
|       <DataSourceHttpSettings | ||||
|         defaultUrl="http://localhost:9200" | ||||
|         dataSourceConfig={options} | ||||
|  |  | |||
|  | @ -181,14 +181,14 @@ describe('ElasticDatasource', () => { | |||
|   }); | ||||
| 
 | ||||
|   describe('When testing datasource with index pattern', () => { | ||||
|     it('should translate index pattern to current day', () => { | ||||
|     it('should translate index pattern to current day', async () => { | ||||
|       const { ds, fetchMock } = getTestContext({ jsonData: { interval: 'Daily', esVersion: '7.10.0' } }); | ||||
| 
 | ||||
|       ds.testDatasource(); | ||||
|       await ds.testDatasource(); | ||||
| 
 | ||||
|       const today = toUtc().format('YYYY.MM.DD'); | ||||
|       expect(fetchMock).toHaveBeenCalledTimes(1); | ||||
|       expect(fetchMock.mock.calls[0][0].url).toBe(`${ELASTICSEARCH_MOCK_URL}/test-${today}/_mapping`); | ||||
|       const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; | ||||
|       expect(lastCall[0].url).toBe(`${ELASTICSEARCH_MOCK_URL}/test-${today}/_mapping`); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| import { cloneDeep, find, first as _first, isNumber, isObject, isString, map as _map } from 'lodash'; | ||||
| import { generate, lastValueFrom, Observable, of, throwError } from 'rxjs'; | ||||
| import { catchError, first, map, mergeMap, skipWhile, throwIfEmpty, tap } from 'rxjs/operators'; | ||||
| import { SemVer } from 'semver'; | ||||
| 
 | ||||
| import { | ||||
|   DataFrame, | ||||
|  | @ -49,7 +50,7 @@ import { metricAggregationConfig } from './components/QueryEditor/MetricAggregat | |||
| import { defaultBucketAgg, hasMetricOfType } from './queryDef'; | ||||
| import { trackQuery } from './tracking'; | ||||
| import { Logs, BucketAggregation, DataLinkConfig, ElasticsearchOptions, ElasticsearchQuery, TermsQuery } from './types'; | ||||
| import { coerceESVersion, getScriptValue, isSupportedVersion } from './utils'; | ||||
| import { coerceESVersion, getScriptValue, isSupportedVersion, unsupportedVersionMessage } from './utils'; | ||||
| 
 | ||||
| export const REF_ID_STARTER_LOG_VOLUME = 'log-volume-'; | ||||
| // Those are metadata fields as defined in https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-fields.html#_identity_metadata_fields.
 | ||||
|  | @ -92,6 +93,7 @@ export class ElasticDatasource | |||
|   includeFrozen: boolean; | ||||
|   isProxyAccess: boolean; | ||||
|   timeSrv: TimeSrv; | ||||
|   databaseVersion: SemVer | null; | ||||
| 
 | ||||
|   constructor( | ||||
|     instanceSettings: DataSourceInstanceSettings<ElasticsearchOptions>, | ||||
|  | @ -119,6 +121,7 @@ export class ElasticDatasource | |||
|     this.logLevelField = settingsData.logLevelField || ''; | ||||
|     this.dataLinks = settingsData.dataLinks || []; | ||||
|     this.includeFrozen = settingsData.includeFrozen ?? false; | ||||
|     this.databaseVersion = null; | ||||
|     this.annotations = { | ||||
|       QueryEditor: ElasticsearchAnnotationsQueryEditor, | ||||
|     }; | ||||
|  | @ -147,13 +150,6 @@ export class ElasticDatasource | |||
|       return throwError(() => error); | ||||
|     } | ||||
| 
 | ||||
|     if (!isSupportedVersion(this.esVersion)) { | ||||
|       const error = new Error( | ||||
|         'Support for Elasticsearch versions after their end-of-life (currently versions < 7.10) was removed.' | ||||
|       ); | ||||
|       return throwError(() => error); | ||||
|     } | ||||
| 
 | ||||
|     const options: BackendSrvRequest = { | ||||
|       url: this.url + '/' + url, | ||||
|       method, | ||||
|  | @ -395,16 +391,24 @@ export class ElasticDatasource | |||
|     return queries.map((q) => this.applyTemplateVariables(q, scopedVars)); | ||||
|   } | ||||
| 
 | ||||
|   testDatasource() { | ||||
|   async testDatasource() { | ||||
|     // we explicitly ask for uncached, "fresh" data here
 | ||||
|     const dbVersion = await this.getDatabaseVersion(false); | ||||
|     // if we are not able to determine the elastic-version, we assume it is a good version.
 | ||||
|     const isSupported = dbVersion != null ? isSupportedVersion(dbVersion) : true; | ||||
|     const versionMessage = isSupported ? '' : `WARNING: ${unsupportedVersionMessage} `; | ||||
|     // validate that the index exist and has date field
 | ||||
|     return lastValueFrom( | ||||
|       this.getFields(['date']).pipe( | ||||
|         mergeMap((dateFields) => { | ||||
|           const timeField: any = find(dateFields, { text: this.timeField }); | ||||
|           if (!timeField) { | ||||
|             return of({ status: 'error', message: 'No date field named ' + this.timeField + ' found' }); | ||||
|             return of({ | ||||
|               status: 'error', | ||||
|               message: 'No date field named ' + this.timeField + ' found', | ||||
|             }); | ||||
|           } | ||||
|           return of({ status: 'success', message: 'Index OK. Time field name OK.' }); | ||||
|           return of({ status: 'success', message: `${versionMessage}Index OK. Time field name OK` }); | ||||
|         }), | ||||
|         catchError((err) => { | ||||
|           console.error(err); | ||||
|  | @ -1040,6 +1044,41 @@ export class ElasticDatasource | |||
|     const finalQuery = JSON.parse(this.templateSrv.replace(JSON.stringify(expandedQuery), scopedVars)); | ||||
|     return finalQuery; | ||||
|   } | ||||
| 
 | ||||
|   private getDatabaseVersionUncached(): Promise<SemVer | null> { | ||||
|     // we want this function to never fail
 | ||||
|     return lastValueFrom(this.request('GET', '/')).then( | ||||
|       (data) => { | ||||
|         const versionNumber = data?.version?.number; | ||||
|         if (typeof versionNumber !== 'string') { | ||||
|           return null; | ||||
|         } | ||||
|         try { | ||||
|           return new SemVer(versionNumber); | ||||
|         } catch (error) { | ||||
|           console.error(error); | ||||
|           return null; | ||||
|         } | ||||
|       }, | ||||
|       (error) => { | ||||
|         console.error(error); | ||||
|         return null; | ||||
|       } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   async getDatabaseVersion(useCachedData = true): Promise<SemVer | null> { | ||||
|     if (useCachedData) { | ||||
|       const cached = this.databaseVersion; | ||||
|       if (cached != null) { | ||||
|         return cached; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const freshDatabaseVersion = await this.getDatabaseVersionUncached(); | ||||
|     this.databaseVersion = freshDatabaseVersion; | ||||
|     return freshDatabaseVersion; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { valid, gte } from 'semver'; | ||||
| import { valid, gte, SemVer } from 'semver'; | ||||
| 
 | ||||
| import { isMetricAggregationWithField } from './components/QueryEditor/MetricAggregationsEditor/aggregations'; | ||||
| import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; | ||||
|  | @ -117,10 +117,13 @@ export const coerceESVersion = (version: string | number | undefined): string => | |||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const isSupportedVersion = (version: string): boolean => { | ||||
| export const isSupportedVersion = (version: SemVer): boolean => { | ||||
|   if (gte(version, '7.10.0')) { | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   return false; | ||||
| }; | ||||
| 
 | ||||
| export const unsupportedVersionMessage = | ||||
|   'Support for Elasticsearch versions after their end-of-life (currently versions < 7.10) was removed. Using unsupported version of Elasticsearch may lead to unexpected and incorrect results.'; | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue